docs: Added documentation for new reactive future call functionality.#447
docs: Added documentation for new reactive future call functionality.#447FXschwartz wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new documentation page describing Serverpod’s new “reactive future calls” feature, explaining how to define handlers that react to database table changes and how the underlying outbox/trigger flow works.
Changes:
- Introduces a new scheduling concepts doc page for reactive future calls.
- Documents
wherefiltering,hasChanged()usage, batching semantics, and runtime trigger management. - Covers operational behavior (polling/scan interval), transaction guarantees, and migration requirements (outbox table).
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | ||
| throw Exception('Something went wrong'); | ||
| }); | ||
|
|
||
| // This WILL trigger react — the transaction is committed | ||
| await session.db.transaction((transaction) async { | ||
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); |
There was a problem hiding this comment.
Inside session.db.transaction, the example inserts a row without passing the transaction object. In the transactions docs, DB ops must receive transaction: transaction to actually be part of the transaction; otherwise this example may commit independently and would trigger the reactive call, contradicting the comment. Update the insert to include the transaction parameter.
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| throw Exception('Something went wrong'); | |
| }); | |
| // This WILL trigger react — the transaction is committed | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| await Trip.db.insertRow( | |
| session, | |
| Trip(status: 'Confirmed'), | |
| transaction: transaction, | |
| ); | |
| throw Exception('Something went wrong'); | |
| }); | |
| // This WILL trigger react — the transaction is committed | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow( | |
| session, | |
| Trip(status: 'Confirmed'), | |
| transaction: transaction, | |
| ); |
There was a problem hiding this comment.
I'm not sure if we really need this change.
There was a problem hiding this comment.
When fact-checking this, I find the suggestion by Copilot to be correct.
Wrapping operations in session.db.transaction((transaction) async { ... }) doesn't automatically enlist them in the transaction.
You have to explicitly opt each database call into the transaction by passing transaction: transaction. Without it, the call runs on its own separate connection and commits immediately. The outer transaction has no hold over it.
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | ||
| throw Exception('Something went wrong'); | ||
| }); | ||
|
|
||
| // This WILL trigger react — the transaction is committed | ||
| await session.db.transaction((transaction) async { | ||
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); |
There was a problem hiding this comment.
Same issue in the committed-transaction example: Trip.db.insertRow(...) should receive transaction: transaction to ensure the insert is performed within the transaction being committed (consistent with the Transactions docs) and to make the example behavior correct.
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| throw Exception('Something went wrong'); | |
| }); | |
| // This WILL trigger react — the transaction is committed | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed'), transaction: transaction); | |
| throw Exception('Something went wrong'); | |
| }); | |
| // This WILL trigger react — the transaction is committed | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed'), transaction: transaction); |
There was a problem hiding this comment.
I don't think we need this change either
There was a problem hiding this comment.
Same here, we should apply it.
| 5. Processed entries are deleted from the outbox. | ||
|
|
||
| :::info | ||
| The outbox scanner uses the same scan interval as regular future calls, configured via the `futureCall.scanInterval` setting in your YAML configuration. |
There was a problem hiding this comment.
Suggestion: this names futureCall.scanInterval but doesn't link to the configuration page that documents it. A reader who wants to actually set the value has to navigate the sidebar manually.
| The outbox scanner uses the same scan interval as regular future calls, configured via the `futureCall.scanInterval` setting in your YAML configuration. | |
| The outbox scanner uses the same scan interval as regular future calls, configured via the `futureCall.scanInterval` setting in your YAML configuration. See [Configuration](configuration) for the full set of future-call options. |
Swiftaxe
left a comment
There was a problem hiding this comment.
Some comments added. Mainly about leaving out implementation details and skipping to what is essential for the user to know.
| @@ -0,0 +1,131 @@ | |||
| # Reactive Future Calls | |||
There was a problem hiding this comment.
We have started adding frontmatter description to all pages for SEO.
Also, use sentence case for headings.
| # Reactive Future Calls | |
| --- | |
| description: React to database row changes in near real-time using Serverpod's reactive future calls, which trigger your handler when inserts, updates, or deletes match a condition. | |
| --- | |
| # Reactive future calls |
| After creating your reactive future call class, run code generation: | ||
|
|
||
| ```bash | ||
| $ serverpod generate |
There was a problem hiding this comment.
We will update this to match the new serverpod start workflow in a different PR.
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | ||
| throw Exception('Something went wrong'); | ||
| }); | ||
|
|
||
| // This WILL trigger react — the transaction is committed | ||
| await session.db.transaction((transaction) async { | ||
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); |
There was a problem hiding this comment.
When fact-checking this, I find the suggestion by Copilot to be correct.
Wrapping operations in session.db.transaction((transaction) async { ... }) doesn't automatically enlist them in the transaction.
You have to explicitly opt each database call into the transaction by passing transaction: transaction. Without it, the call runs on its own separate connection and commits immediately. The outer transaction has no hold over it.
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | ||
| throw Exception('Something went wrong'); | ||
| }); | ||
|
|
||
| // This WILL trigger react — the transaction is committed | ||
| await session.db.transaction((transaction) async { | ||
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); |
There was a problem hiding this comment.
Same here, we should apply it.
|
|
||
| Reactive future calls let you react to database changes in near real-time. When a row is inserted, updated, or deleted, Serverpod can automatically and asynchronously invoke your code with the changed data. This is useful for scenarios like sending notifications when an order is confirmed, syncing data to external systems, or triggering workflows based on state changes. | ||
|
|
||
| Under the hood, reactive future calls use PostgreSQL triggers and an outbox pattern. A database trigger fires within the same transaction as the data change, writing an entry to an outbox table. Serverpod periodically polls the outbox on a configurable scan interval and dispatches matching entries to your handler. Because the trigger runs in the same transaction, rolled-back changes never produce events. |
There was a problem hiding this comment.
The users don't need to know how things work under the hood. What they need to know here is that the rolled-back changes never produce events. We can skip directly to the point.
| Under the hood, reactive future calls use PostgreSQL triggers and an outbox pattern. A database trigger fires within the same transaction as the data change, writing an entry to an outbox table. Serverpod periodically polls the outbox on a configurable scan interval and dispatches matching entries to your handler. Because the trigger runs in the same transaction, rolled-back changes never produce events. | |
| Because the trigger runs in the same transaction, rolled-back changes never produce events. |
|
|
||
| Your reactive future calls are automatically discovered and registered alongside regular future calls. No manual registration is needed. | ||
|
|
||
| ## How it works |
There was a problem hiding this comment.
There are many technical details here that the user probably does not need to concern themselves with.
Consider just saying something like for the whole ## How it works section:
How it works
Reactive future calls use a scan interval. The same interval as regular future calls, configured via futureCall.scanInterval. Serverpod polls for new entries at this interval, so handlers fire in near real-time, rather than instantly.
Rows that accumulate between scans are batched together and delivered to react in a single call. This is why react receives a List rather than a single object.
| } | ||
| ``` | ||
|
|
||
| The `where` getter defines a condition that becomes a `WHEN` clause on the PostgreSQL trigger. Only changes matching this condition will create outbox entries. In the example above, the trigger only fires when the `status` column equals `'Confirmed'`. |
There was a problem hiding this comment.
Mentioning 'outbox' is an implementation details of no concern to our users.
Could we boil these two paragraphs down to the essentials? Perhaps something like:
The react method is your handler. It's called with every row that matches the condition. The where getter filters which changes reach the handler.
|
|
||
| ## Transaction safety | ||
|
|
||
| Because the trigger runs inside the same database transaction as the data change, reactive future calls have transactional guarantees: |
There was a problem hiding this comment.
Suggestion: this section talks about transaction commit/rollback semantics without linking to the transactions docs. A reader unfamiliar with session.db.transaction has to find it themselves.
| Because the trigger runs inside the same database transaction as the data change, reactive future calls have transactional guarantees: | |
| Because the trigger runs inside the same database transaction as the data change, reactive future calls have transactional guarantees (see [Transactions](../database/transactions) for the underlying API): |
| // This will NOT trigger react — the transaction is rolled back | ||
| await session.db.transaction((transaction) async { | ||
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | ||
| throw Exception('Something went wrong'); | ||
| }); | ||
|
|
||
| // This WILL trigger react — the transaction is committed | ||
| await session.db.transaction((transaction) async { | ||
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | ||
| }); |
There was a problem hiding this comment.
Nit: em-dashes in code comments. The style guide discourages them in running text, and comments read as prose to the reader. Swap for periods or colons.
| // This will NOT trigger react — the transaction is rolled back | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| throw Exception('Something went wrong'); | |
| }); | |
| // This WILL trigger react — the transaction is committed | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| }); | |
| // This will NOT trigger react. The transaction is rolled back. | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| throw Exception('Something went wrong'); | |
| }); | |
| // This WILL trigger react. The transaction is committed. | |
| await session.db.transaction((transaction) async { | |
| await Trip.db.insertRow(session, Trip(status: 'Confirmed')); | |
| }); |
Here is the documentation covering new reactive future call feature serverpod/serverpod#4882